Day 26:幫協程寫測試,runTest 與虛擬時間排程器 結尾留下一個問題,前面累積的所有觀念,放進一個真實一點的專案裡會長成什麼樣子。今天正面回答這個問題。
從 Day 01:為什麼你需要協程,從一個會卡住主執行緒的下載函式說起 定案多來源圖片下載與聚合工具作為示範情境開始,這 26 天走過的每一天,取用的都只是這個工具的其中一個切片,一個會卡住畫面的陽春下載函式、平行下載多個來源、取消、例外隔離、用 Flow 回報進度、用 runTest 驗證行為。每個切片都各自完整,卻從來沒有被放在一起看過全貌。
今天要做的事情,正是把這些切片正式收斂成一個具名的完整版本,圖片下載工具完整版。這個名稱底下沒有任何新的示範情境,它就是同一個從 Day 1 就存在的情境,第一次被完整地組裝起來。從今天開始,接下來談到示範專案時,都會直接使用這個名稱,不再需要重新解釋它的來歷。
圖片下載工具完整版由五個部分組成,每一個部分都對應到系列前面某個階段定案的觀念。
第一個部分是負責平行下載的核心模組。這個模組接收一組圖片網址,對每個來源各自啟動一個子協程進行下載,內部依賴的正是 Day 07:Structured Concurrency,為什麼協程不能亂長亂放 定案的結構化並發規則,用一個父 Job 管理這些子協程的生命週期,確保呼叫端等到的永遠是所有子協程都收斂完畢之後的結果,不會有子協程孤兒般地在背景繼續跑。
第二個部分是執行環境設定,決定下載工作與後續的圖片處理各自該用哪一種 Dispatcher。網路請求交給適合處理輸出入等待的 Dispatcher,圖片解碼這類消耗運算資源的工作,則交給另一種適合密集運算的 Dispatcher,這部分直接沿用 Day 12:Dispatchers,協程實際跑在哪條執行緒上 的判斷準則。
第三個部分是例外治理設計。多個來源同時下載時,某一個來源失敗不該波及其他來源的結果,這個隔離行為靠 SupervisorJob 達成,任何沒有被個別捕捉的例外,統一交給 CoroutineExceptionHandler 攔截處理,這正是 Day 18:小結:協程的執行環境與例外治理工具箱總覽 收斂過的工具組合。
第四個部分是進度回報。整體下載過程用 Flow 持續發送目前完成了幾個來源、還有哪些正在進行,再包一層 StateFlow,讓畫面隨時都能查詢當下最新的進度快照,這部分呼應 Day 25:小結:Flow 資料流工具箱總覽,準備進測試與收斂期 整理過的資料流工具箱。
第五個部分是測試。一組使用 runTest 撰寫的測試案例,驗證逾時判斷是否正確、部分來源失敗時是否確實被隔離而不影響其他來源,這部分承接 Day 26:幫協程寫測試,runTest 與虛擬時間排程器 定案的測試工具。
這五個部分放在一起,關鍵程式碼片段大致長這樣:
class ImageDownloader(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val cpuDispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
private val _progress = MutableStateFlow(DownloadProgress.empty())
val progress: StateFlow<DownloadProgress> = _progress
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
Log.w("ImageDownloader", "未攔截的例外,來源可能已被個別隔離", e)
}
suspend fun downloadAll(urls: List<String>): List<DownloadResult> =
supervisorScope {
urls.map { url ->
async(ioDispatcher + exceptionHandler) {
downloadOne(url).also { updateProgress(url) }
}
}.awaitAll()
}
private suspend fun downloadOne(url: String): DownloadResult =
try {
withTimeout(5_000) {
val raw = fetchBytes(url)
withContext(cpuDispatcher) { decode(raw) }
}
DownloadResult.Success(url)
} catch (e: CancellationException) {
throw e
} catch (e: TimeoutCancellationException) {
DownloadResult.Failure(url, e)
} catch (e: Exception) {
DownloadResult.Failure(url, e)
}
}
這段程式碼沒有出現任何前面沒教過的新機制,只是把 Day 1 到 Day 26 各天定案的觀念,依照各自負責的職責,安放進同一個專案該有的位置。如果讀者對其中某一段感到陌生,回頭對照對應天數的內容即可,這裡不重新教學各項機制本身的定義,也不打算展示完整可執行的專案原始碼,重點放在結構與職責分工。
這裡的 catch 順序值得特別看一眼。CancellationException 被放在最前面單獨接住,且做的事情只有 throw e 重新拋出,不會被吞掉,因為協程真正的取消訊號正是靠這個例外傳遞,一旦意外攔截下來,父層就再也感知不到子協程已經被取消。TimeoutCancellationException 本身是 CancellationException 的子類別,這裡刻意寫在下一個 catch,把逾時視為一種需要轉換成 DownloadResult.Failure 的已知失敗情境,與純粹的取消訊號分開處理。最後的 catch (e: Exception) 才是真正撐起「例外隔離」這件事的關鍵,fetchBytes 或 decode 拋出的任何其他例外,例如網路錯誤或解碼失敗,都會在這裡被攔下並轉換成失敗結果,不會向外傳播波及其他來源的下載。
結構描述完了,接著看這個專案裡一個具體的測試案例,驗證某個來源下載逾時時,整體聚合結果是否仍正確排除該來源,同時保留其他來源的成功結果。
@Test
fun `其中一個來源逾時時應隔離該來源並保留其他成功結果`() = runTest {
val downloader = ImageDownloader(
ioDispatcher = StandardTestDispatcher(testScheduler),
)
val results = downloader.downloadAll(
listOf("https://example.com/slow.jpg", "https://example.com/fast.jpg"),
)
val failures = results.filterIsInstance<DownloadResult.Failure>()
val successes = results.filterIsInstance<DownloadResult.Success>()
assertEquals(1, failures.size)
assertEquals(1, successes.size)
}
這段測試直接對應前一節提到的例外隔離設計,fast.jpg 這個來源不會因為 slow.jpg 逾時而被牽連,downloadAll 回傳的結果裡,兩者的成敗各自獨立。這裡示範的只是逾時這一種失敗情境,downloadOne 實際上能攔住的失敗類型不只如此,前一節談過的一般性例外,例如網路錯誤,同樣會在同一層被轉換成 DownloadResult.Failure,不會另外寫一套邏輯處理。
downloadOne 內部設定的逾時時間是五秒,測試裡刻意模擬的慢速來源延遲也超過五秒,但整段測試執行起來幾乎是瞬間完成,不需要真的等上五秒。這正是 Day 26 定案的 TestCoroutineScheduler 在背後接管了時間的感知,runTest 建立的範圍裡,withTimeout 內部依賴的 delay 被虛擬時間推進取代,測試邏輯完全沒有打折扣,變快的只有實際等待的時間。這裡看到的不再是 Day 26 那個孤立的小範例,而是測試工具在一個具備多來源、例外隔離、平行協程的真實結構裡,實際發揮效益的樣子。
ImageDownloader 這個核心模組,從頭到尾用到的都是純粹的 Kotlin 協程語言機制,平行下載、結構化並發、Flow 資料流,沒有依附任何特定應用框架。這正是這個系列一路強調的重點,語言機制優先於框架用法,這裡第一次用實際案例印證這件事。
如果把這套邏輯放進一個 Android 應用裡,畫面層大概只需要用一個生命週期感知的 Scope 啟動 downloadAll,再訂閱 progress 這個 StateFlow,隨著進度更新畫面上的顯示。ImageDownloader 本身完全不需要更動,Android 那一層要處理的只是啟動時機與畫面更新,這裡不深入 Android 生命週期元件本身的運作細節,只想呈現核心邏輯幾乎原封不動,只是換了個地方被呼叫。
如果換成放進一個 Ktor 後端服務,情境會更單純,某個處理請求的 suspend function 裡直接呼叫 downloadAll,拿到聚合結果後回傳給呼叫端即可,甚至不需要額外處理生命週期,因為請求本身的生命週期就足以框住這次呼叫。這裡同樣不展開 Ktor 路由設計或伺服器組態的細節,重點只有一個,核心協程邏輯本身可攜,不需要為了不同框架重寫一套。
今天正式定案了圖片下載工具完整版這個具名示範專案,把前 26 天累積的平行下載、結構化並發、Dispatcher 選擇、例外治理、Flow 進度回報、runTest 測試,全部安放進同一個結構裡各自該在的位置。downloadOne 這個函式也一併定案了處理失敗的完整寫法,CancellationException 必須原樣拋出,TimeoutCancellationException 與其他一般例外則各自轉換成失敗結果,不會互相波及。Android 與 Ktor 的簡短對照,印證了核心協程邏輯不依附任何框架,可以原封不動搬到不同執行環境。
看起來這套寫法已經很完整,但完整不代表沒有陷阱。下一篇要回頭檢視幾個容易被忽略的寫法,看看哪些地方即使程式碼能跑,也可能悄悄埋著問題。